← 返回首页

vllm-ascend PR #18671 解读:GLM V1 SP 布局贯穿 DSA-CP——十行 attention 到昇腾实现全链

PR 地址:vllm-project/vllm-ascend · Pull Request #18671([Performance][Model] Preserve GLM V1 SP token layout through DSA-CP)
[Performance][Model] Preserve GLM V1 SP token layout through DSA-CP —— 让 SP 的 token 分片布局一路贯穿 DSA-CP,消掉重复的 AllGather / ReduceScatter

1. PR 档案

项目内容
标题[Performance][Model] Preserve GLM V1 SP token layout through DSA-CP
作者 / 状态wzx0726 · DRAFT(草稿,待验证)创建于 2026-10-14(状态截至撰写时)
目标分支releases/v0.30.0(正是我们 1009 环境的版本线:vllm 0.30.0 + vllm-ascend 0.19.1rc2)
改动规模10 个文件,+923 / −34 行(5 个源码文件 + 5 个测试文件;diff 实测,PR 较初稿有更新)
涉及模块vllm_ascend/models/deepseek_mtp.py、ops/mla.py、attention/sfa_v1.py、attention/context_parallel/sfa_cp.py、attention/sfa_kv_offload.py(另含 5 个测试文件)
硬件实测环境GLM-5.1 W8A8C8 + MTP=3,双 A3 节点,TP=16 DP=2,V1 runner,SP/DSA-CP 开启,max-num-seqs=12,max-num-batched-tokens=8192 —— 与我们的排查环境完全一致
读前须知:四个名词 30 秒(更多缩写见文末第 8 章术语速查)
一句话总结 GLM 在 V1 runner + 序列并行 MoE(SP)+ DSA-CP 的组合下,模型层进 attention 前先把本地 token 分片 AllGather 成全局,DSA-CP 又把它切回本地段;算完再经全局布局 ReduceScatter 回来——每层、每个混合批次都白做一次"聚合→再切→散射"的布局往返。本 PR 让同一份连续 TP token 分片贯穿 Decoder Layer、MLA 包装层、DSA-CP attention 三个边界,通信只剩 attention 内部必须的一次 all-to-all + reduce-scatter。
图 1 · PR 改动全景:5 个源码文件沿调用链排布,5 个测试文件组成回归网
PR #18671 改动全景:+923 / −34 · 10 个文件 源码 ×5(沿调用链排布,与第 4 章层级一一对应) models/deepseek_mtp.py 模型层:两条 forward 路与模型类换装(链上起点) ops/mla.py 门卫:契约四查 + 新入口 forward_sequence_parallel attention/sfa_v1.py 调度员:forward 加两参,输入 / 输出两处分流 attention/context_parallel/sfa_cp.py 车间:契约声明 + ⑤ 输入校验 + ⑥ o_proj 收口 attention/sfa_kv_offload.py KV offload:透传两个新参数(小改,与门卫/九步无接触) 测试 ×5(回归网) test_deepseek_dsa_cp.py(新增) 直通正确性 / 回退矩阵 / FP16 缩放 test_sfa_o_proj_weight_switch.py o_proj 两路通信 / padding / 缩放 test_mla.py 能力探测矩阵 / metadata 拒错 / dummy batch test_sfa_cp.py 本地 SFA 分发 / 索引缓存所有权 / nope 组合 test_glm_moe_dsa.py(新增) GLM MoE DSA 模型选择 / 换装回归(PR 新增) 五个源码文件按调用顺序排布;测试覆盖明细见第 4 章测试表

2. 问题:布局来回倒腾(改动前)

先理清三方角色,关键是看懂 SP 怎么切。TP=16 的组里,3504 个 token(3500 的 prompt 对齐到 16 的倍数,多出的 4 个是 padding)按连续段横向切开——每卡分到 219 个,这就是模型层(DecoderLayer)手里的本地 token 分片。MoE / MLP 天然可切:各卡算各自那 219 个 token 的分片就行,谁也不需要别人的。DSA-CP attention 同样按 token 切卡:每卡只对自己那 219 个 token 的 Q 做注意力,缺的 KV 跨卡定点去取(③)。按理说模型层和 attention 拿的都是分片、布局本该一致,但原实现里对不上——老约定认为「attention 必须在全量 token 上算」,于是有了下面的 ①②④(这张切法的全貌见本节末的图 3)。

图 2 左侧是业务主线(与并行无关的算子链),右侧是并行框架挂上去的通信步骤,本 PR 只动右侧。原路径的问题:框架在 LN→注意力之间先 ① AllGather 聚成全局([1,219,8192] → [1,3504,8192])、attention 入口 _prepare_native_hidden_states 又 ② 切回本地段,算完 O 投影输出全局布局、框架再 ④ ReduceScatter 收回——①②④ 全是无用功,每层都来一遍;③ 取命中 KV 是 DSA-CP 本职通信。

改动前 · 左=业务主线,右=框架线(并行框架挂的通信) 用户输入:3500 字 prompt Token 化(词表 128k) ids [1, 3500] Token 嵌入 ≈ 1.05B 128k×8192 → [1, 3500, 8192] 框架 · SP 切片(TP=16) 每卡 [1, 219, 8192] Block × N(每层 ≈ 1.1B) LayerNorm [1, 219, 8192] 多头注意力 ≈ 0.27B 64 头 × 128 Q 投影 67M K 投影 67M V 投影 67M 注意力计算 Q·Kᵀ/√d → softmax → ·V O 投影 67M 残差连接 + LayerNorm [1, 219, 8192] FFN(SwiGLU)≈ 0.8B 上投影 up 8192×32768 门控 gate 8192×32768 SwiGLU 激活 下投影 down 32768×8192 残差连接 + LM Head(与嵌入共享权重) 8192×128k → logits [1, 128k] 生成下一个 Token decode 重复整条链,KV 从缓存取 ① AllGather(冗余) [1,219,8192] → [1,3504,8192] ① ② 再切本地段(冗余) 全局 → 本地分片 ② ③ 取命中 KV(本职) 每 Q 拉 top-k=2048 ③ ④ ReduceScatter(冗余) 全局 → 本地分片 ④ 实线=业务主线 虚线=框架线挂的通信步骤
图 2 · 改动前:左=业务主线,右=框架线;①②④ 为冗余通信、③ 为 DSA-CP 本职

①~⑥ 速查(后文正文与代码注释均用此编号)

编号名称位置与性质
①AllGather模型层 LN→注意力之间,分片→全局,冗余
②再切本地段attention 入口 _prepare_native_hidden_states,全局→分片,冗余
③取命中 KVattention 内部,每 Q 拉 top-k=2048,DSA-CP 本职
④ReduceScatter模型层 O 投影之后,全局→分片,冗余
⑤输入校验 + padding 清零新增,纯本地操作、无通信,接替 ② 的防错位职责
⑥all-to-all + 单次 reduce-scatterattention 内部 o_proj 收口,归约职责并入此处
图 3 · SP 切法示意:3504 = 16 × 219,每卡一段连续 token
SP 怎么切:全局 3504 token(3500 + 4 padding)→ 16 段连续分片 卡0 卡1 卡2 卡3 卡4 卡5 卡6 卡7 卡8 卡9 卡10 卡11 卡12 卡13 卡14 卡15 第 16 段的尾部含 4 个 padding(3500 对齐到 16 的倍数 = 3504) 放大 ↓ 卡 0 的本地分片 [219, 8192] 连续的 219 个 token MoE / MLP:各卡各算各段 无需任何通信,天然可切 attention(老约定):要全量 → ① AG 聚全局 → ② 切回 → ④ RS 这就是 ①②④ 的用武之地:不是模型需要它们,是「attention 必须全量算」的老约定需要—— 本 PR 用布局契约把这个老约定改掉:分片从一而终(改后的样子见下图 4)

3. 核心思路:本地分片贯穿三个边界

PR 立了一条布局契约。DSA-CP attention 后端声明 supports_sequence_parallel_input = True,含义是"我收本地分片,还回本地分片"。对照图 4(业务骨架与图 2 完全一致):①② 两处冗余直接省掉;② 的防错位职责由新增的 ⑤ 输入校验 + padding 清零(纯本地操作)接替;④ 的归约职责并入 attention 内部的 ⑥ all-to-all + 单次 reduce-scatter;③ 本职通信原样保留。这样三个边界(DecoderLayer ↔ MLA 包装层 ↔ DSA-CP attention)看到的是同一份连续 TP token 分片。

改动后 · 业务主线与图 2 完全一致;只动框架线:灰=省掉、绿=新增、蓝=变化 用户输入:3500 字 prompt Token 化(词表 128k) ids [1, 3500] Token 嵌入 ≈ 1.05B 128k×8192 → [1, 3500, 8192] 框架 · SP 切片(不变) 每卡 [1, 219, 8192] Block × N(每层 ≈ 1.1B) LayerNorm [1, 219, 8192] 多头注意力 ≈ 0.27B 64 头 × 128 Q 投影 67M K 投影 67M V 投影 67M 注意力计算 Q·Kᵀ/√d → softmax → ·V O 投影 67M 残差连接 + LayerNorm [1, 219, 8192] FFN(SwiGLU)≈ 0.8B 上投影 up 8192×32768 门控 gate 8192×32768 SwiGLU 激活 下投影 down 32768×8192 残差连接 + LM Head(与嵌入共享权重) 8192×128k → logits [1, 128k] 生成下一个 Token decode 重复整条链,KV 从缓存取 ① AllGather 省掉:分片直进注意力 ① ② 再切本地段 省掉:⑤ 接替防错位 ↓ ② ⑤ 新增:校验 + padding 清零 本地操作,无通信 ⑤ ③ 取命中 KV(不变) DSA-CP 本职 ③ ⑥ 变化:all_to_all + 单次 RS O 投影:换 head → 投影 → 单次归约 FP16:scale 归约前乘 ⑥ ④ ReduceScatter 省掉:归约已并入 ⑥ 业务主线未变(与图 2 一致) 灰=省掉、绿=新增、蓝=变化
图 4 · 改动后:业务主线一个框都没动;只动框架线——①②④ 省掉、⑤ 新增、⑥ 变化、③ 不变

3.1 为什么 ①② 能省

因为这是一对互逆操作。① AllGather 的存在理由,是上游 SP 的老约定——"attention 必须在全量 token 上算",所以每层都要把每卡 [1,219,8192] 聚成全局 [1,3504,8192]。但 DSA-CP 的注意力天生按 token 分片算:每张卡只算自己那 219 个 token 的 Q 行,从来不需要别的卡上的 token;它唯一要的跨卡数据是命中的 KV 块,而那部分它自己通过 ③ 去拉。于是 ① 聚出来的全局 token,唯一的消费者就是紧跟着的 ②(再切回本地段)——聚了又切,切完就扔,两步互相抵消,每层每批次白付一次 AllGather 通信加两次布局整理。

3.2 为什么 ④ 能省

因为它的活儿已经有人干了。跨卡归约(把各卡的部分和加起来)这件事必须做、且只能做一次:改动后由 ⑥ 在 attention 内部完成——all_to_all 换完 head 后,数据正好是"每卡一段 token 的部分和",紧接着一次 reduce_scatter 同时完成求和和按卡散射,一步到位。模型层原来那个 ④ 如果保留,要么重复归约(结果就错了),要么得先把数据倒腾回旧布局再散一次(通信翻倍)。所以它不是简单删掉,而是职责移进了唯一正确的位置。至于正确性:⑤ 的边界校验保证布局不可能悄悄错(错就当场抛错),FP16 的 scale 放在归约前乘,与上游语义逐位对齐——4.10 节的单测验证了新旧路径结果一致。

4. 从十行 attention 样例到昇腾实现全链

先对齐地基。第一张图先看全貌——模型架构 → 框架流程(图 5):模型的每一件在 vllm-ascend 里住哪、谁调它。本章沿一条固定路线走,小节对应六步:4.1 架构全貌(图 5)→ 4.2 目录地图(图 6)→ 4.3 十行样例怎么被完成(流水线图 7)→ 4.4 全链时序(图 8)→ 4.5 为什么这么实现·逐步收益(图 9)→ 4.6 问题 → 4.7 这次 PR(图 10~14),4.8~4.10 三张表收拢,每张图配一段讲解。先说结论:attention 的数学一行没动;"那一堆"其实是两样东西——这份代码在框架里的原样出现,加上为了在 16 张卡上跑起来、在它周围加的通信与选址。

4.1 先看全貌:模型架构 → 框架流程

模型架构 → 框架流程:模型的每一件,在 vllm-ascend 里住哪、谁调它 模型架构(自上而下) 框架落点 · 调用者(文件:行号实测) Token 嵌入 词表 128k · hidden 8192 上游 runner 喂 ids [1,3500],嵌入成 [1,3500,8192]; pad 到 3504 在 Runner 做(model_runner_v1.py:2935) × N 层 DecoderLayer 每层≈1.1B,外壳串四件 层外壳 forward:input LN → self_attn → post LN → MoE (deepseek_v2.py:1315);SP 时外壳先 ①AG 聚全量、 算完 ④RS 散回——PR 要拆的就是这两行通信 └ self_attn(十行样例) MLA/DSA:latent KV+top-2048 门卫 ops/mla.py:151 放行 → 后端 sfa_v1.py:1503 九步流水线开算;GLM 与 DeepSeek 共用这条实现链 └ MoE(路由+共享) SwiGLU,≈0.8B/层 SP MoE:各卡各算自己 219 个 token 的分片,天然免通信 (mlp 收 already_sequence_parallel=True,不再整理布局) MTP 模块(投机解码) 环境 MTP=3 ★ vllm_ascend/models/deepseek_mtp.py ★:本 PR 换装闸门在此—— AscendDSACPDecoderLayer/Model 从这里生效, test_glm_moe_dsa.py 钉死 GLM 选路 LM Head(与嵌入共享) logits [1,128k] Runner 收回输出送采样;decode 每出一个 token, 再从第 2 行整条链走一遍(KV 从页表缓存取) 读法:左列=模型自上而下的架构件;右列=它在框架里的落点与调用者;黄行 MTP 是本 PR 唯一改到的模型件(★)。全篇链子:Runner→层外壳→门卫→后端九步。 下一步:先认目录分工(图 6),再从十行样例进流水线(图 7);完整调用时序见时序图(图 8),每步为什么这么实现见展开图(图 9)。
图 5 · 模型架构 → 框架流程:模型的每一件在 vllm-ascend 里的落点与调用者(★ = 本 PR 改动落点)

4.2 先认目录:与 attention 相关的目录,一个一个认

与 attention 相关的目录:左 = 目录名,右 = 它管什么、为什么有它 vllm_ascend/worker/ 执行与喂料 接引擎的批 → pad 到 3504 → 调模型、收回输出 为什么有它:上游执行器绑死 NVIDIA,昇腾必须有自己的执行器 vllm_ascend/models/ 模型结构 ★ 层怎么搭、forward 怎么走 —— 十行样例的调用者 为什么有它:模型结构上游都有,昇腾只改个别层,继承上游小改 vllm_ascend/ops/ 算子门面 ★ 把昇腾算子包成 vLLM 认识的"层";attention 的门卫(mla.py)在这 为什么有它:调用约定不同,模型层只认门面、不认具体硬件 vllm_ascend/attention/ 注意力实现 ★ self_attn 的后端:九步流水线(sfa_v1.py:1503)在这里真正开算 为什么有它:注意力后端可插拔(不同硬件/并行形态各一套),策略独立成模块 …/attention/context_parallel/ DSA-CP 特化(车间)★ ⑤ 校验 · ③ 取命中 KV · ⑥ 收口 —— 上下文并行的专属逻辑 为什么有它:上下文并行(KV 与计算再切多卡)的特化单独放一个子目录 另有 patch/(运行时补丁)与 platform.py(平台注册"我是昇腾")也在链路上,但与 attention 无直接关系,知道即可
图 6 · 目录介绍:★ = PR 改动文件所在的目录(5 个源码文件全在这四个目录里)

四个目录,一条链。worker/ 管执行与喂料,models/ 管模型结构,ops/ 管算子门面,attention/ 管注意力实现——目录名就是分工,谁也不越界。

其中两个目录要特别留意:models/ 是十行样例的调用者——层对象把 input LN、self_attn、post LN、MoE 串成一次 forward;ops/ 和 attention/ 是十行样例的实现者——门卫放行后,九步流水线把每一行数学落地。为什么实现要拆成两个模块?ops/ 管"怎么接进 vLLM 的层体系",attention/ 管"注意力策略本身"——接法和算法是两件事,所以分家。

4.3 十行样例,vllm-ascend 原本是怎么完成的

先摆出十行样例代码——矩阵乘、RoPE、KV Cache、softmax,十来行(GQA + KV Cache 的典型写法,batch 维省略):

# attention 朴素样例(推理版,等价于典型的 GQA+KVCache 教程实现)
def forward(x, is_prefill):                            # x: [1, seq, hidden]
    q = x @ wq                                         # L1  Q 投影
    k = x @ wk;  v = x @ wv                            # L2  K/V 投影
    q = rope(q, pos);  k = rope(k, pos)                # L3  RoPE,只转 Q/K
    k_cache = k if is_prefill else cat(k_cache, k)        # L4  KV Cache:整条历史拼在本卡
    v_cache = v if is_prefill else cat(v_cache, v)
    k_full = k_cache.repeat_interleave(4, dim=1)       # L5  GQA:2 组 KV 复用给 8 个 Q 头
    scores = q @ k_full.transpose(-1,-2) / sqrt(dk)    # L6  打分——隐含假设:全量历史都在本卡
    if is_prefill: scores = causal_mask(scores)        # L7  因果遮罩
    w = softmax(scores, dim=-1)                        # L8  归一化
    out = w @ v_full                                   # L9  加权求和
    return out @ wo                                    # L10 输出投影
attention 样例 → vllm-ascend 怎么完成它:左列实线是十行样例的流向,虚线弧线指向它在昇腾里的落点 attention(样例代码) vllm-ascend 的实现流水线 在哪个模块 · 门牌(最全) L1 Q 投影 q = x @ wq L2 K/V 投影 k=x@wk, v=x@wv L3 RoPE 只转 Q、K L4 KV Cache cat 追加历史 L5 GQA repeat 复用 KV 给 Q 头 L6 打分 q @ kᵀ/√dk L7 因果遮罩 遮住未来 token L8 softmax 归一化 L9 加权和 w @ v_full L10 输出投影 out @ wo 层外壳发起 self_attn 门卫放行 → 后端 步3 Q/KV 联合低秩投影(KV 段) 步4 K 侧 rope · 步6 Q 侧 rope 步4 写本卡缓存 · 步7 全量写回 无此步(latent KV 全头共享) 步8 稀疏注意力算子(QKᵀ) 步8 内:causal 遮罩处理 步8 内:softmax 归一化 步8 内:·V 加权求和 步9 收口(o_proj)→交回层外壳 attention/ 步3:1576 (Q 低秩段)· 步6:1614 Q 上投影 矩阵乘原样,拆低秩两段 attention/ 步3(KV 段) 步4 :1595 exec_kv 投影+rmsnorm+写cache 融成一个昇腾算子 attention/ :1595(K 侧) :1616(Q 侧) 同一旋转,没变 融进算子,数学一致 attention/ :1626 reshape_and_cache slot 页表逐 token 定位 代替整块 cat 追加 attention/ MLA·DSA 特性 latent KV 全头共享 目的相同(省 KV) 手段不同,无独立步骤 attention/ 步8 :1683 内 QKᵀ/√dk 原样 只在 top-2048 块上算 稀疏,不扫全量历史 attention/ 步8 算子内 causal 遮罩由算子处理 prefill 生效 decode 无需遮罩 attention/ 步8 算子内 softmax 原样 归一化没变 数值在算子内部完成 attention/ 步8 算子内 ·V 加权原样 输出 [219,全头] 等待收口 attention/ :1695 o_proj 原路:直接投影 SP 场景另有 ⑥ 两路 (见 PR 流水线图) 读法:左列实线 = 十行样例自上而下,一行一格、零省略;弯曲虚线 = 每一行在 vllm-ascend 里的落点;右列门牌最全(模块 / 文件:行号 / 为什么)。 此时还没有 PR 的事——这就是 vllm-ascend 平时跑这份 attention 的样子。
图 7 · 流水线(原本):十行样例在 vllm-ascend 里的完成过程(没有这次 PR 时的样子)

看懂这张图,记住三件事。

一、十行数学一步没少。左列每一行,中列都有对应——投影、RoPE、写缓存、稀疏注意力、收口,数学原样;唯一的例外是 L5(GQA repeat),MLA 系用 latent KV 全头共享达到同样目的,所以没有独立步骤。

二、实现分散在三个模块,但只有一条调用链。models/ 发起 → ops/ 放行 → attention/ 干活;右列就是每一站的地盘和门牌(文件:行号)。

三、此时还没有 PR 的事。这就是 vllm-ascend 平时完成这份 attention 的完整样子。它什么时候出问题、这次 PR 改了它哪里,往下看。

4.4 全链时序:谁调用谁,每一棒先自己干再交棒

图 7 看的是"数学落在哪"。这份工作是谁发起、又一层层交给谁的?下面把整条调用链按时间拉开——六个类六条泳道,每个函数先标"自己干了啥",再标"委托给谁":

vllm-ascend 跑 attention 样例:全链时序——每个函数先"自己:"干完,再"委托:"下一棒(文件:行号实测) 上游 vLLM 引擎 组批·调度 core.py:595 Worker 昇腾插件 NPUWorker worker.py:643→669 执行器·喂料 NPUModelRunner model_runner_v1.py :2035 主入口 模型层 models/ AscendDeepSeekMTP deepseek_mtp.py:23 层外壳上游 :1315 门卫 ops/mla.py AscendMultiHead LatentAttention :151/:163/:181 后端 attention/ AscendSFAImpl / AscendSFADSACPImpl sfa_v1.py:1503 sfa_cp.py:292 自己:组批——调度器把多条请求 拼成一个批;每条请求的 attention 各自算各自的(按请求边界遮罩,互不 串);批越大 NPU 一趟吃越满(core.py:595) 委托:execute_model(批)——整批交给 Worker 引擎此后只收 output,再组下一批 自己:不碰 attention 数学——监控统计; PP 按层切段部署时,段与段之间收发 hidden(下一段 attention 就吃这份输入) (worker.py:643→669) 委托:model_runner.execute_model(:669) ——喂料员接手,把批变成算子要的形状 自己:喂料——pad 3500→3504(16 的倍数, 昇腾矩阵单元才吃得满);再组注意力 元数据:每条请求的边界+KV slot 起点 ——attention 算子按它切请求,免造 整块大 mask 张量(:2935·build :3294) 委托:模型 forward——层外壳逐层发起, 每层一次 attention+MoE 自己:MTP 投影层(draft,与主层同构—— 这里也住着一个 attention)——加载期 rot 一次性把权重排成后端要的形状, 免得每次 forward 重排(deepseek_mtp.py:23) 自己(层外壳):LN→①聚全量(每卡 219 hidden 做 AG 拼成 3504——十行样例的 hidden_states 输入,就是聚完的它)→ self_attn→④散回→postLN→MoE(:1315) 委托:self_attn(positions,hidden)—— 十行样例,从这一行进入框架 自己:预建输出张量 output——十行 的 attn_output 落这;预分配免碎片 (mla.py:151) 自己:custom op 直通车(:163)—— 编译器到此止步,防图优化把后端 的融合算子拆散——护住省带宽的融合 自己:取层权重/KV 句柄/批元数据, 凑齐后端开算要的三样再递进去 (mla_forward 函数体) 委托:impl.forward(层名,hidden,kv, 元数据,output)(:181)——进车间 impl 谁干活,加载期 resolve_sfa_impl 四 选一定死(:1303)——运行时每层直接调, 零选路开销;开 DSA-CP 选 AscendSFADSACPImpl(sfa_cp.py:292) 步准备:DSACPContext 立坐标——本卡管 哪段:local_start=rank×219;全量 3504=round_up(3500,16)(:1527/CP:188) 后面九步全按这套坐标切活:Q 按卡 切段(各 219),KV 不切(全量各存) 步3:Q/KV 联合低秩投影(:1576)——十行 的 q/k/v_proj 三个矩阵乘合成一枪; KV 压成低秩 latent——cache 按它记账 不按头数记,这就是省显存的源头 步4:exec_kv 融合算子(:1595)——十行 里 K/V 侧的投影+rmsnorm+rope+写 cache 融成一枪;中间结果不落显存(省带宽) ③:跨卡取命中 KV 拼全量(:1605)——算 QKᵀ 要全量历史 K,本卡只存了 1/16; 只取 indexer 命中的块,不全搬—— 通信量按命中块数走,不按历史长度走 步6:Q 投影+Q 侧 rope(:1614/:1616)—— 十行的 q_proj+rope(q);Q 不进 cache, 随算随转,不占显存 步7:写回全量 cache(_store_parallel_kv :1626)——本卡算出的 latent KV 每卡 各存一份,供别卡日后 ③ 来取—— 代价 KV×16 份,拿显存换互通 选块:indexer 选 top-2048 块(:1670)—— 十行里没有这步(DSA 加的):长历史里 真正相关的只有少数块,先挑后算 步8:稀疏注意力算子(:1683)——十行的 QKᵀ/遮罩/softmax/·V 四步数学在这 一个算子里出齐;分数矩阵全程不落 显存——省带宽,这正是融合的意义 步9:收口 o_proj(:1695)——十行的 o_proj 原样;输出只剩本卡的 219 段, 交回门卫→层外壳,后面 ④ 散回各卡 (原路 :1384) output 原路返回:后端→门卫→层外壳 →Runner→Worker→引擎,再进下一层 读法:"自己:"框=自己干的活——每框写清两件事:跟十行样例什么关系、省什么(显存/带宽/算力);"委托:"箭头=交给下一棒;右侧自转圈=九步流水线(同图7 中列)。 每层都走一遍右半边,本图只画一次;impl 在加载期定死。PR 在门卫加"契约四查"、在后端加 ⑤⑥——本图画的是原本,见 4.7。
图 8 · 全链时序图:从引擎到后端,每个类"自己做什么 / 委托什么"——每框写明跟十行的关系与省什么(文件与行号本地实测)

读这张图抓三个节奏。

一、委托链只有一条,没有岔路。引擎组批 → Worker 接批 → Runner 喂料 → 层外壳发起 → 门卫放行 → 后端开算;每一棒只把活交给下一棒,谁也不越级。

二、"自己干"的都是壳上的杂活,"委托"出去的才是正餐。Worker 自己只做监控与流水线收发;Runner 自己只做 pad 和组元数据;把十行样例真正算出来的,是最右边后端的那串自转圈——九步流水线。

三、门卫是一趟直通车。torch.ops 这层 custom op 让编译器到此止步:层外壳不感知硬件,后端不感知模型,两边都只认门卫;后端是谁,加载期就由 resolve_sfa_impl 四选一定死了。

4.5 为什么这么实现:逐步收益

图 7 回答了"落在哪",图 8 回答了"谁调用谁",还剩最后一个问题:每一步为什么这么实现?各自省的是什么?下面把图 7 中列的流水线原样搬到最左列,向右逐层展开——怎么做(带门牌)→ 为什么这么设计 → 收益点:

每一步为什么这么实现:流水线(最左)→怎么做→为什么→收益点,逐层展开 昇腾实现流水线 (=图7 中列) 怎么做 · 门牌 (本地实测) 为什么这么设计 收益点(省什么) 层外壳 self_attn 门卫放行 → 后端 L1 的调用者 层外壳串 LN→①AG→attn →④RS;门卫 :151 放行 层管布局,门卫管接入—— 模型代码不碰硬件细节 接法与算法分家 换后端只动门卫 模型层零改动 多卡逻辑收在壳上 步3 Q/KV 联合 低秩投影(KV 段) fused_qkv_a_proj(:1576) 一次矩阵乘同出 Q 路 1536 与 KV latent,后段再上投影 MLA 先压低秩 latent, 数学等价;KV 账本按 latent 记,不按全头记 KV Cache 不随头数 翻倍——省显存是大头; 三投影合一省访存 步4 K 侧 rope 步6 Q 侧 rope K 侧旋转融进 exec_kv (:1595);Q 侧 rope_single (:1616)就地转 K 转后才写 cache, Q 不进 cache 随算随转 ——顺序不能反 少一次中间张量读写; 旋转数学与 L3 逐位一致 步4 写本卡缓存 步7 全量写回 slot 页表逐 token 定位写 (:1626);DSA-CP 跨卡拼 全量、每卡各存一份(:1605) 页表代替整块 cat; 每卡全存是 ③ 跨卡取 命中块的前提 无大拷贝、无碎片; 代价 KV×16 份—— 拿显存换正确性 无此步(GQA) latent 全头共享 GQA 的 repeat 没有对应物: latent KV 生来全头共用 (:1595 段内) GQA 用 repeat 凑复用; MLA 把复用直接做进 结构里 显存不随 Q 头数翻倍; 省掉 repeat 的拷贝 与算力(对比 L5) 步8 稀疏注意力 算子(QKᵀ) indexer 先选 top-2048 块 (:1670);算子只在选中块 上 QKᵀ/√dk(:1683) 长历史里相关的只有 少数块;索引器先挑, 注意力再算 算力从扫全量历史降到 2048 块;序列越长 省得越多 步8 内: causal 遮罩处理 遮罩由算子内部按 prefill/decode 自动处理 (:1683 算子内) 遮罩是算子固定语义, 不必在外面造 mask 张量 省 mask 张量的显存 与生成耗时;prefill 遮未来,decode 免遮 步8 内: softmax 归一化 softmax 在算子内部完成, 与 QKᵀ 连续执行 (:1683 算子内) 三步数学融为一个 算子,中间结果不落地 分数矩阵不写显存—— 省带宽,这正是融合 的意义 步8 内: ·V 加权求和 ·V 加权同算子出结果 [219,全头],停在收口前 (:1683 算子内) 同一条算子流水线, 一次出全部分数结果 每卡只算自己 219 个 token 的输出,天然 按卡并行 步9 收口(o_proj) →交回层外壳 _finalize_o_proj(:1695; 原路 :1384)投影后交回; 开 SP 走 ⑥ 两路(见后图) 输出投影保持原样; 通信只在 SP 需要 时才加 不开 SP 零额外通信; 开 SP 归约只做一次 (o_proj 两路图) 读法:最左列=图7 中列的流水线原样搬来;向右三列逐层展开——怎么做(带门牌)→为什么这么设计→收益点。口径:3504 全量/219 每卡/16 卡/top-2048 块。 本图讲"原本为什么这么实现"——①④⑤⑥ 是壳上的事:①④ 在层外壳,⑤ 在输入准备,⑥ 在收口;九步数学一步没动。PR 怎么改,见 4.7。
图 9 · 昇腾实现展开图:最左列 = 图 7 中列流水线;向右逐层展开「怎么做 → 为什么 → 收益点」

逐行看收益,"省"分三种,省的东西不一样。

省显存的:低秩 latent 让 KV Cache 不随头数翻倍(步3/无此步两行);slot 页表定位省掉整块 cat 的大拷贝(步4/步7 一行)。

省带宽的:融合算子让中间结果不落显存——投影+rmsnorm+rope+写缓存一个算子(步4),QKᵀ→softmax→·V 一个算子(步8 三行);分数矩阵从头到尾不写回显存。

省算力的:稀疏注意力只算 indexer 选中的 top-2048 块,不扫全量历史;序列越长,省得越多。

也有一笔反向账要认:DSA-CP 每卡都存全量 KV(16 份)——这是 ③ 跨卡取命中块的前提,拿显存换正确性;这笔账 PR 之后依然在(PR 不动 ③)。

PR 在这张图里的位置:①④ 在层外壳(第一行),⑤ 在输入准备,⑥ 在收口(最后一行)——三处全在"壳"上,九步数学一步没动。往下就看 PR 怎么拆壳。

4.6 开了 SP + DSA-CP,原本的跑法出了问题

从引擎点到这段代码:模块 → 类 → 方法(改前 · 上游层) 引擎 模块(定位) 类 方法 · 在这 上游 vLLM · 引擎 core.py:595 组批调执行 models/ · 模型结构 上游管所有模型的层 DeepseekV2DecoderLayer 一层的封装·十行样例住这 forward() 串 LN/attn/MoE,①④ 在内 层里除了十行样例还有前后两个 LN 和一个 MoE · deepseek_v2.py:1315,有删节 · 为什么讲它:①④ 不在十行里,在层的外壳上
图 10 · 改前上游层的户口:模块 → 类 → 方法(deepseek_v2.py:1315,有删节)

L6 之所以写得那么短,是因为 L4 已经把全量历史 KV cat 进了同一张卡——"所有 token 的 KV 我都拿得到"是单卡默认成立的隐含假设。16 卡把 token 切成 16 段后,每张卡只分到 219 个 token,这个假设碎了。上游 vLLM 的解法很直接:每层先把 token 聚回全量,让 attention 照旧在全量上跑,算完再散回去——这就是第 2 章的 ① 和 ④(上游 deepseek_v2.py:1315,有删节):

# 上游每层都在十行样例外面套了这两行(有删节)
if input_is_sequence_parallel:
    hidden = tensor_model_parallel_all_gather(hidden, 0)   # ① 219 → 3504:把假设"买"回来,每层白付一次
hidden = self.self_attn(positions, hidden, ...)            # ← L1~L10 在这里面,照旧全量跑
...
hidden = tensor_model_parallel_reduce_scatter(hidden, 0)   # ④ 3504 → 219:再把假设"退"回去

更亏的是 L4:DSA-CP 的注意力本来就要跨卡取命中的 KV 块(③),于是 ① 聚出来的全量 token 转头又被切回本地段(②)——聚了又切,切完就扔,每层每批都来一遍。这就是第 2 章图 2 的灰块;问题不在十行数学,在"喂给它的数据布局"来回倒腾。

检测技巧:程序怎么知道"输入已经是分片"? 对比两个长度——positions 是全量长度(3504,每个 token 的位置编号都要有),hidden_states 是本卡分片长度(219)。形状对不上,就说明输入已被切成 SP 分片。不需要任何额外开关,一个 shape 比较就够了。

4.7 这次 PR:拆掉聚散,分片直通

从引擎点到这段代码:模块 → 类 → 方法(改后 · PR 新层) 引擎 模块(定位) 类 方法 · 在这 上游 vLLM · 引擎 照常调到这一层 vllm_ascend/models/ 模型结构·继承上游小改 AscendDSACPDecoderLayer 那一层的昇腾版 forward() 三查不过走老路,过则直通 为什么住 models/:改的是"层怎么走",不是算子 · 类继承上游 DeepseekV2DecoderLayer
图 11 · PR 新层的户口:模块 → 类 → 方法(vllm_ascend/models/deepseek_mtp.py)

PR 的模型层改成:分片直接进 attention,十行样例在分片上原样跑:

# PR 新增:vllm_ascend/models/deepseek_mtp.py
class AscendDSACPDecoderLayer(DeepseekV2DecoderLayer):
    def forward(self, positions, hidden_states, residual, llama_4_scaling=None):
        input_is_sequence_parallel = (self.use_sequence_parallel_moe
                                      and residual is not None
                                      and hidden_states.shape[0] != positions.shape[0])
        # 回退三条件:不是分片 / 带缩放的特殊批 / attention 契约不支持 → 原样走 4.6 的上游老路
        if (not input_is_sequence_parallel
                or llama_4_scaling is not None
                or not self.self_attn.mla_attn.supports_sequence_parallel_input):
            return super().forward(positions, hidden_states, residual, llama_4_scaling)

        hidden_states, residual = self.input_layernorm(hidden_states, residual)
        output_scale = 1.0 / self.routed_scaling_factor if hidden_states.dtype == torch.float16 else 1.0
        hidden_states = self.self_attn.mla_attn.forward_sequence_parallel(
            positions, hidden_states, output_scale=output_scale)   # 分片直进,无① 无④
        if hidden_states.dtype == torch.float16 and self.layer_idx == 0:
            residual *= 1.0 / self.routed_scaling_factor           # FP16 防溢出:第 0 层 residual 同步缩放
        hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
        hidden_states = self.mlp(hidden_states, already_sequence_parallel=True)
        return hidden_states, residual
这次 PR 改了什么:左列实线是原本的流向,虚线弧线指向 PR 的做法,右列解释最全 vllm-ascend 原本(开 SP+DSA-CP) 这次 PR 的代码 / 做法 解释(最全) 层外壳:① AG 聚全量 →attn→④ RS 散回(每层) 输入:上游喂全量 后端步2 切段(②) KV:跨卡拼全量 每卡全存(步5/步7) 稀疏注意力(步8) 只在自己 token 的 Q 上算 收口:o_proj 直算 (步9 原路,全量投影) 闸门不过走老路 (行为 = 改前) 层直通:AscendDSACP- DecoderLayer 分片直进 (回退三条件) ⑤ 新增:校验分片边界+清零 只查不切 (四项断言) 不变:③ 跨卡取命中 KV 每卡都存全量 (下层直接用) 不变:QKᵀ→softmax→·V 十行数学零改动 ⑥ 新增:两路收口 路A 全权重零通信 路B all_to_all+单次RS 闸门:类4条件→契约四查 →后端契约四选一 新类住 models/ (deepseek_mtp.py) ①聚了又切 = 白做,拆掉 三查不过走老路,行为不变 sfa_cp.py(车间模块) 断言:token/pad/start/end 与本 rank 对齐,错位抛错 clone 后清零,不碰 residual attention/ 本职通信 ③ 是 DSA-CP 存在的理由 PR 一行不碰 ——它本来就是对的 attention/ :1683 只算 indexer 选的 top-2048 块 遮罩在算子内部 sfa_cp.py :507 起 16 卡各算一段 归约只做一次 FP16 先乘 output_scale 任何一级不满足 整条链回左列老路 4.6 的回退矩阵单测 逐场景钉死 读法:左列实线 = 原本每一步;弯曲虚线 = PR 在这一步动了什么(或为什么不动);右列解释最全(模块 / 文件 / 行号 / 细节)。 FP16:output_scale = 1/routed_scaling_factor 在归约前乘,第 0 层 residual 同步缩放——与上游防溢出语义逐位对齐。
图 12 · 流水线(这次 PR):左列原本(黄 = 问题),中列 PR 做法(粗体 = 改动),右列解释;灰行 = 闸门兜底,不支持就回老路

这张图就是整个 PR。左列是原本(开了 SP+DSA-CP 的跑法),中列是 PR 的做法,逐行对着看。

改的是三处。层外壳:不再 ① 聚全量 / ④ 散回,分片直进(新类 AscendDSACPDecoderLayer,住 models/);输入侧:新增 ⑤ 只查不切(四项断言,防静默错位);输出侧:新增 ⑥ 两路收口(路 A 全权重零通信,路 B all_to_all + 单次 reduce_scatter——归约只做一次,FP16 缩放在归约前乘)。

不动的是三处。③ 跨卡取命中 KV、步 8 的稀疏注意力数学、门卫与闸门的兜底——任何一级条件不满足,整条链回左列老路,行为与改前完全一致。

_finalize_sequence_parallel_o_proj:两条路,都还回本地 token 分片 attention 输出 attn_output 路 A:full head + full weight 换全套 o_proj 权重直接算 输出即完整本地 token(copy_) 无需任何集合通信 路 B:head 分片(常规 TP) [本地token, 全head] → all_to_all → [全token, 本地head] → o_proj(未归约) → 先乘 output_scale → 1 次 reduce_scatter 关键细节:FP16 时 output_scale = 1/routed_scaling_factor 在归约前乘——保留上游的防溢出缩放语义
图 13 · o_proj 收口:路 A 全权重直算,路 B all-to-all + 单次 reduce-scatter,尾部 padding 清零
AscendGlmMoeDsaForCausalLM 的模型选择:四个条件全中才走新路径 条件1 · GLM MoE DSA use_v2 = False 且 条件2 · PP = 1 无流水线切分 且 条件3 · SP MoE 开启 use_sequence_parallel_moe 且 条件4 · enable_dsa_cp() additional_config 开关 → AscendDSACPModel(新路径) 分片直进:收分片、还分片 上游 DeepseekV2Model 任一条件不满足 → 原路径 AG + 切段 + RS 行为不变 注:MLA 包装层另有能力探测(4.7),不满足同样自动回退
图 14 · 生效条件:V1 runner + PP=1 + SP MoE + DSA-CP(其余模型 / V2 / PP / DCP 一律走原路径)

4.8 对照总表:十行每一行,在这次 PR 里去哪了

前面把"原本怎么跑"和"PR 怎么改"都走完了,这张总表把十行样例与 PR 新路径的对应关系收拢到一起(文件与行号全部经本地源码实测):

样例行所在模块在 vllm-ascend 里(DSA-CP 新路径)变了什么
L1 · Q 投影attention/fused_qkv_a_proj(sfa_v1.py:1576,Q/KV 联合低秩投影)→ _q_proj_and_k_up_proj(:1614,Q 侧上投影)同一个矩阵乘,拆成低秩两段(q_lora 1536 压缩,省参数省带宽)
L2 · K/V 投影attention/exec_kv(:1595 → npu_kv_rmsnorm_rope_cache 昇腾算子)同一个投影,与 rmsnorm、写缓存融合成一个硬件算子
L3 · RoPEattention/K 侧在上面的算子内;Q 侧 rope_single(:1616)同一个旋转,没变
L4 · KV Cacheattention/context_parallel/_store_parallel_kv(:1626 → DeviceOperator.reshape_and_cache)不再整块 cat:slot_mapping(页表)指定每个 token 的 KV 落在哪一格;且 DSA-CP 先把 16 卡的 KV 拼成全量、每卡都存一份(为什么,见 4.6/4.7)
L5 · GQA repeat—(无对应物)没有对应物:DSA 是 MLA 系,latent KV 天然全头共享目的相同(省 KV),手段不同
L6~L9 · 打分/遮罩/归一化/加权attention/_execute_sparse_flash_attention_process(:1683 → :1171,昇腾稀疏注意力算子)QKᵀ → softmax → ·V 原样都在;变化有二:只在 indexer 选出的 top-2048 个 KV 块上算(稀疏),且融成一个硬件算子,遮罩在算子内部处理
L10 · 输出投影attention/context_parallel/_finalize_o_proj(:1695;SP 路径为 _finalize_sequence_parallel_o_proj,sfa_cp.py)同一个投影;PR 在这里加了一次 all_to_all + 单次 reduce_scatter(⑥),因为 16 卡各算一段(4.7 讲)
(朴素样例里没有)attention/context_parallel/⑤ _prepare_sequence_parallel_hidden_states(sfa_cp.py):校验分片边界 + 清零 padding防错位的安全阀:分片错了当场抛错,不悄悄算错

4.9 PR 落点总表

文件挂在哪一行 / 哪一步改动一句话
vllm_ascend/models/deepseek_mtp.py十行样例外面(模型层 forward)新增 AscendDSACPDecoderLayer(分片直进 + 回退三条件)与 AscendDSACPModel(换装 + 建层)
vllm_ascend/ops/mla.pyattention 模块(门卫)契约四查 + 新入口 forward_sequence_parallel + custom op 传参/校验
vllm_ascend/attention/sfa_v1.pyattention 后端(九步本体)forward 加两参,输入准备 / 输出收口两处分流,原路全保留
vllm_ascend/attention/context_parallel/sfa_cp.pyattention 后端(⑤⑥ + 契约)契约属性 + ⑤ 校验清零 + ⑥ 两路收口 + impl 四选一
vllm_ascend/attention/sfa_kv_offload.pyattention 后端(KV offload)KV offload 路径透传两个新参数(小改)

4.10 测试(5 个文件,覆盖面很扎实)

📍 链上定位 · 横跨第 4 章全部改动点 · 角色:给整条新链路织回归网(正确性、回退、缩放语义逐项钉死)

文件覆盖点
test_deepseek_dsa_cp.py(新增)混合批次下连续两层的本地分片与 residual 保持(4 rank × 3 dtype);5 种不支持布局全部回退上游路径;FP16 缩放逐层对齐;模型选择条件矩阵;构造器权重名 / 共享索引 buffer 不变量
test_sfa_o_proj_weight_switch.py(+207 行)每个 rank 的混合布局与复制路径结果一致;TP 尾 padding;full / 分片投影两条路的通信正确性(all-to-all + RS 各一次);FP16 归约前缩放;布局 metadata 不一致拒错
test_sfa_cp.py本地 SFA 分发、索引缓存所有权、gated 投影、无 RoPE(nope)组合
test_mla.py(+142 行)能力探测矩阵(backend × shard_size × custom_op × reduce_results);metadata 不匹配拒错;dummy batch 零输出不通信;默认分发不变
test_glm_moe_dsa.py(PR 更新后新增)GLM MoE DSA 模型选择 / 回归(第 5 个测试文件,对应换装闸门)

5. 前后代码对照(带注释)

"改后"代码均摘自 PR 实际 diff(有删节,只保留主干);"改前"的模型层为上游 vLLM 真实代码(DeepseekV2DecoderLayer.forward,deepseek_v2.py,有删节)。绿色加粗行为本 PR 新增逻辑。注释里的 ①~⑥ 序号对应图 2 / 图 4 的通信步骤标记(① AllGather、② 再切本地段、③ 取命中 KV、④ ReduceScatter、⑤ 输入校验、⑥ all-to-all+单次RS)。

5.1 模型层 forward:从"AG → 算 → RS"到布局直通

改前 · 上游 vLLM 真实路径(deepseek_v2.py:1315 DeepseekV2DecoderLayer.forward,有删节)
# 上游约定:attention 要"全量 token",SP 的 MoE 要"本地分片"——两头迁就,每层倒腾一次
def forward(self, positions, hidden_states, residual, llama_4_scaling=None):
    full_num_tokens = positions.shape[0]                       # 全量 token 数(3504)
    input_is_sequence_parallel = (self.use_sequence_parallel_moe
                                  and residual is not None
                                  and hidden_states.shape[0] != full_num_tokens)  # 219 ≠ 3504
    hidden_states, residual = self.input_layernorm(hidden_states, residual)
    if input_is_sequence_parallel:
        hidden_states = tensor_model_parallel_all_gather(hidden_states, 0)  # ← 图2-① 分片聚成全局,每层白做
        hidden_states = hidden_states[:full_num_tokens]
    hidden_states = self.self_attn(positions, hidden_states, llama_4_scaling)  # attention 在全量上算
    ...
    hidden_states = tensor_model_parallel_reduce_scatter(hidden_states, 0)      # ← 图2-④ 全局散回分片,每层白做
    hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
    hidden_states = self.mlp(hidden_states, already_sequence_parallel=True)
    return hidden_states, residual
改后 · AscendDSACPDecoderLayer(PR 新增)
class AscendDSACPDecoderLayer(DeepseekV2DecoderLayer):
    def forward(self, positions, hidden_states, residual, llama_4_scaling=None):
        # 判定输入是否已是"本地 token 分片":SP MoE 开启 + residual 存在
        # + 本地 token 数 != 全局位置数(分片的典型特征)
        input_is_sequence_parallel = (
            self.use_sequence_parallel_moe and residual is not None
            and hidden_states.shape[0] != positions.shape[0]
        )
        # 首层 replicated 输入 / 不支持的投影契约 → 原样走上游,行为不变
        if (not input_is_sequence_parallel
                or llama_4_scaling is not None
                or not self.self_attn.mla_attn.supports_sequence_parallel_input):
            return super().forward(positions, hidden_states, residual, llama_4_scaling)

        # 直通链(图2):省掉 ①④,全程无 AllGather / ReduceScatter
        hidden_states, residual = self.input_layernorm(hidden_states, residual)
        # FP16 防溢出:把 routed_scaling 缩放交给 attention 在 TP 归约"前"做
        output_scale = 1.0 / self.routed_scaling_factor if hidden_states.dtype == torch.float16 else 1.0
        # 收分片、还分片(走 4.7 的新入口):①④ 在此省掉,归约见 5.3 的 ⑥
        hidden_states = self.self_attn.mla_attn.forward_sequence_parallel(
            positions, hidden_states, output_scale=output_scale)
        # FP16 且第 0 层:residual 同步缩放,逐位对齐上游 overflow 保护
        if hidden_states.dtype == torch.float16 and self.layer_idx == 0:
            residual *= 1.0 / self.routed_scaling_factor
        hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
        # 告诉 MLP "输入已是 SP 分片",MLP 不再做布局整理
        hidden_states = self.mlp(hidden_states, already_sequence_parallel=True)
        return hidden_states, residual

5.2 输入准备:从"全局切段"到"校验分片"

图 15 · 输入准备一图看懂:改前是「切」,改后是「查」
改前 · 上游刚 AG 出全局,这里切出本段(= ②,刚聚就切): 全局 hidden [3504, 8192] 按 local_start 切片 hidden[start:end](= ②) 本段 [219, 8192] 进注意力 改后 · 上游直接给分片,这里只查不切(= ⑤,纯本地、无通信): 分片 [219, 8192] 模型层直接传入 校验分片边界 start/end/pad 不一致 → 抛错 padding 清零 先 clone 再清(防污染 residual) ✓ 对应下面的 5.2 代码;校验是防「静默错位」的安全阀——布局错了当场抛错,而不是悄悄算错
改前 · _prepare_native_hidden_states(仍保留,供原路径用)
# 进来的是"全局 token"(上游已 AllGather),在这里切出本 rank 的一段
# —— 图2-②:刚聚的全局,转头又切回本地,正是要消掉的浪费
return hidden_states[context.local_start : context.local_end_with_pad]
改后 · _prepare_sequence_parallel_hidden_states(PR 新增)
def _prepare_sequence_parallel_hidden_states(self, hidden_states, attn_metadata):
    context = attn_metadata.dsa_cp_context
    tp_group = get_tp_group()
    local_tokens = hidden_states.shape[0]
    expected_start = tp_group.rank_in_group * local_tokens
    # ⑤ 新增(接替图2-② 的位置):校验"本 rank 那段",不一致立刻抛错(防静默错位)
    if (context.num_tokens != attn_metadata.num_input_tokens
            or context.num_tokens_pad != local_tokens * self.tp_size
            or context.local_start != expected_start
            or context.local_end_with_pad != expected_start + local_tokens):
        raise RuntimeError("SFA DSA-CP input does not match its sequence-parallel shard: ...")
    # Graph/DP 的 padding 区清零;先 clone 再清,避免污染 residual 的共享存储
    valid_tokens = max(0, min(local_tokens, context.num_tokens - context.local_start))
    if valid_tokens < local_tokens:
        hidden_states = hidden_states.clone()
        hidden_states[valid_tokens:].zero_()
    return hidden_states

5.3 输出收口 o_proj:路 A 全权重直算,路 B all-to-all + 单次 RS

改后 · _finalize_sequence_parallel_o_proj(PR 新增;对照图 13)
def _finalize_sequence_parallel_o_proj(self, attn_output, output, gather_full_o_proj,
                                       attn_metadata, output_scale):
    if gather_full_o_proj:
        # 路 A:full head + full weight —— 直接算出完整本地 token,零集合通信
        with self._use_full_o_proj_weights():
            output.copy_(self._apply_o_proj_full_weight(attn_output))
        if output_scale != 1.0:
            output.mul_(output_scale)
    else:
        # 路 B(⑥,常规 TP head 分片):attn_output 是 [本地token, 全head],
        # view 成 [本地token, tp, head/tp] 后 permute,使 tp 维落到最前;
        # all_to_all 后变成 [全token, 本地head] —— 每张卡拿到全部 token 的自己的 head
        send = (attn_output.view(-1, self.tp_size, self.num_heads * self.v_head_dim)
                .permute(1, 0, 2).reshape(-1, self.num_heads * self.v_head_dim))
        sharded_output = torch.empty_like(send)
        tp_group = get_tp_group()
        torch.distributed.all_to_all_single(sharded_output, send, group=tp_group.device_group)
        projected_output = self.o_proj(sharded_output)[0]      # 行并行:结果未归约
        if output_scale != 1.0:
            projected_output.mul_(output_scale)                # FP16:归约"前"缩放
        # ⑥ 单次 reduce_scatter = 归约 + 散射 → 本地分片(图2-④ 在此消失)
        torch.distributed.reduce_scatter_tensor(output, projected_output.contiguous(),
                                                group=tp_group.device_group)
    context = attn_metadata.dsa_cp_context
    valid_tokens = max(0, min(output.shape[0], context.num_tokens - context.local_start))
    if valid_tokens < output.shape[0]:
        output[valid_tokens:].zero_()                          # 尾部 padding 清零
    return output

5.4 能力声明与探测:安全阀

# sfa_cp.py —— 两个后端一真一假,DCP 明确不硬吃契约
class AscendSFADSACPImpl(OProjWeightSwitchMixin, AscendSFAImpl):
    supports_sequence_parallel_input = True   # DSA-CP:TP 组内按 token 分片 → 支持

class AscendSFADCPImpl(DCPImplMixin, AscendSFAImpl):
    supports_sequence_parallel_input = False  # DCP:query/head 两轴分别恢复 → 不支持

# ops/mla.py —— MLA 包装层的四条件探测,任一不满足自动回退原路径
@property
def supports_sequence_parallel_input(self) -> bool:
    impl = self.mla_attn.impl
    return (
        self.output_token_shard_size == 1                          # 输出按 token 分片
        and getattr(impl, "supports_sequence_parallel_input", False)  # 后端声明支持
        and impl.o_proj.custom_op is None                          # 无自定义通信算子
        and not impl.o_proj.reduce_results                         # o_proj 不自带 reduce
    )

5.5 模型选择:四个条件全中才换新路径

改后 · AscendGlmMoeDsaForCausalLM.__init__(对照图 14)
class AscendGlmMoeDsaForCausalLM(GlmMoeDsaForCausalLM):
    def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
        if (not vllm_config.use_v2_model_runner                        # 条件1:V1 runner(use_v2=False)
                and vllm_config.parallel_config.pipeline_parallel_size == 1  # 条件2:PP=1
                and vllm_config.parallel_config.use_sequence_parallel_moe    # 条件3:SP MoE 开启
                and enable_dsa_cp()):                                  # 条件4:enable_dsa_cp
            self.model_cls = AscendDSACPModel    # 全中 → 换布局直通的原生模型
        super().__init__(vllm_config=vllm_config, prefix=prefix)
        # 不满足任一条件 → 走原 DeepseekV2Model,行为完全不变

6. 与我们排查问题的关系(重点)

这个 PR 测的就是我们的场景 PR 描述里的硬件测量环境:GLM-5.1 W8A8C8、MTP=3、两台 A3、TP=16 DP=2、V1 runner、SP/DSA-CP、max-num-seqs=12、max-num-batched-tokens=8192、chunked prefill、FULL_DECODE_ONLY,压测命令就是 aisbench_test.py --input_len 3500 --output_len 1500 --data_num 32 --concurrency 8 --request_rate 0 —— 与我们 135/136 双机的口径完全相同。

它优化的正是 SP + DSA-CP 路径上的冗余通信,作用点在 prefill/decode 混合批次的每一层。我们之前测得:enable_prefill_mc2 无收益(TTFT 739→753-768 持平),1009 相对 0815 的 TTFT 差距锁定在 DispatchFFNCombine kernel 回退——而这个 PR 是另一条独立的优化轴(省模型层与 attention 边界的 AG/RS),两者不冲突,理论上可叠加。

PR 报告的单次候选测量:TTFT 均值 529.3ms、TPOT 21.4ms、输出吞吐 348.7 tokens/s、零 KV 抢占;GSM8K 4-shot 全量 1319 题 97.57%。

注意两点:① PR 自己声明这是"单次候选测量,非受控对比",不能直接跟我们 739-768ms 的数比(版本线、镜像都不同);② GSM8K 也无基线对照。但 TTFT 529 这个量级至少说明该路径优化后没有明显劣化。

7. 收益、风险与待验证

收益 符合条件的 GLM V1 SP/DSA-CP 批次:每层省 2 次全量激活的集合通信 + 2 次布局整理;混合 prefill/decode 批次(我们 3.5K+1.5K、cc8 正是这种)受益最直接。无需新配置项,条件满足自动生效。
安全性设计 能力探测四条件不满足即回退;布局 metadata 不一致抛错且不通信;DCP / V2 / PP / 其他模型完全不动;FP16 缩放语义逐位对齐上游并有单测。
待验证 PR 自述:新增单测尚未运行;release-port 头未在 NPU 上复跑;format.sh ci 因本地缺 pre-commit 未完成;其他模型 / 并行 / dtype 组合未验证。当前为 draft。
对我们的行动建议 这个 PR 直接打在我们环境(vllm-ascend 0.30.0 线 + SP/DSA-CP)的痛点上。值得:

8. 术语速查

术语本 PR 语境下的含义
SP token 分片SP MoE 下模型层的工作布局:每卡只持全局 token 的 1/TP 连续段
DSA-CPDeepSeek 稀疏注意力 + 上下文并行:Indexer 选 top-k、跨卡定点取 KV(第 2 章 ③ 取命中 KV)
布局契约supports_sequence_parallel_input:后端承诺"收本地分片、还本地分片"
all_to_all_single路 B 中把 [本地token, 全head] 换成 [全token, 本地head] 的 head 交换
reduce_scatter_tensoro_proj 行并行未归约 partial 的"归约+散射"一步完成,产出本地 token 分片
output_scaleFP16 防溢出:1/routed_scaling_factor,在 TP 归约前乘到 partial 上
index cache / topk bufferIndexer 命中索引的层间共享缓冲,新模型类保持全层共享同一 buffer

来源:github.com/vllm-project/vllm-ascend PR #18671(draft,base: releases/v0.30.0,+923/−34,10 文件)· 本页为技术解读,数据均引自 PR 原文

华杉的技术博客 · 苏ICP备2026063680号-1
首页